Repository navigation
Fail a rolling restart job that did not restart every node - #3498
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces a sequential rolling restart mechanism (restartEachPeer) that restarts peers one by one and polls for completion, halting if any peer fails. It also refactors job insertion to propagate errors directly by removing try-catch blocks in jobs.ts and serverUtilities.ts, and adds comprehensive unit tests. Feedback was provided to remove a redundant and inefficient Array.from() call on pSearchByValue() in jobs.ts.
kriszyp
force-pushed
the
fix/rolling-restart-job-outcome
branch
from
October 24, 2026 02:38
89fcf1e to
f13f3cc
Compare
A replicated restart_service (the job behind deploy_component restart: 'rolling') recorded a peer it could not restart and still completed, so a caller polling get_job saw COMPLETE while that peer ran old code. It now ends ERROR naming each peer that failed or was skipped, with every peer's outcome kept in result.replicated. - restartEachPeer: retries a peer that refused for write back-pressure after the other peers (three attempts), polls get_job sequentially and keeps polling through a connection drop instead of an unhandled rejection, bounds every request to a peer, and halts the roll at a peer whose restart failed or timed out. - addJob rethrows the search/insert error it used to swallow, and executeJob refuses a missing createdJob, so a failed job insert surfaces its own error instead of "Empty job passed to parseMessage". - jobProcess keeps a failed job's err.result. Refs #3468 Dispatch-Task: harper-3468-rolling-restart-outcome Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dispatch the replicator reports as having an unknown outcome (a timeout, or the connection closed before the answer) may have started that peer's restart, so moving on could restart two peers at once. Such a peer now halts the roll, as does an answer with no job id. A peer job in ERROR without a message, or with a status the poller does not know, also counts as failed, and a poll that times out names its last failure. The executeJob test fails a real job-row encode instead of a sinon stub. Dispatch-Task: harper-3468-rolling-restart-outcome Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the peer Only a peer's own error answer or a connection that never opened (ECONNREFUSED and similar) lets the roll move on; any other socket error halts it. A poll that succeeds clears the last poll failure, so a timeout on a reachable peer does not blame the network. Dispatch-Task: harper-3468-rolling-restart-outcome Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pending The session rejects requests pending at close with a code-less 'Connection closed' error, which read as a peer's own answer, so the roll moved on while the peer could be restarting. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-0346cd78c3159a8a3a9f365e1b7c7aec
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-0346cd78c3159a8a3a9f365e1b7c7aec
kriszyp
force-pushed
the
fix/rolling-restart-job-outcome
branch
from
October 24, 2026 04:45
f930897 to
b323922
Compare
kriszyp
marked this pull request as ready for review
October 26, 2026 12:41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
A rolling restart (
deploy_componentwithrestart: "rolling", or a replicatedrestart_service) reportedCOMPLETEeven when a peer was never restarted; that node kept serving old code. On the peer, a failed job insert was reported asEmpty job passed to parseMessageinstead of its real cause (a write-queue 503). Polling a restarting peer also leaked unhandled rejections. Reported in #3468.🤔 Is ERROR the right outcome, rather than a new partial status?
Yes: any job can already end
ERROR, soget_jobcallers handle it, and the per-node detail stays inresult.replicated. A new status would be one more value every client must learn.🤔 Backport to v5.3?
Recommended: the report came from a 5.3.3 cluster and the change applies there unchanged. It changes what
get_jobreports for a failed roll, so it is your call; the milestone is unset.💡 Solution
ERRORnaming each peer that failed or was skipped.result.replicatedkeeps one entry per peer:{ node, message }or{ node, error }.⚖️ Alternatives
setIntervalpoller overlapped requests and leaked rejections, so the poller needed rewriting anyway.sendOperationToNode— it forwards every operation and cannot know which are safe to repeat.COMPLETEand documentresult.replicated— a caller pollingstatusstill cannot tell a node runs old code.🤔 Should a peer that could not be reached stop the roll?
No: a peer that returned its own error, or whose connection never opened, did not start a restart. The roll continues and the job still ends
ERROR. Any other dispatch failure halts it so two peers never restart at once.🤔 Is matching error text acceptable for the retry and halt decisions?
Errors cross replication as text plus a socket
code, the only signal today; both 5.3 and current wordings match. A structured code is #3496.🔧 Changes
bin/restart.ts: newrestartEachPeerandawaitPeerJobreplace the inline replicated loop inrestartService: per-peer outcomes, back-pressure retry queue, halt/skip, bounded dispatch and poll timeouts.mayHaveStarted, which decides whether a failed dispatch continues or halts the roll.server/jobs/jobs.ts:addJobrethrows its search and insert errors instead of returning{ success: false }with no job.server/serverHelpers/serverUtilities.ts:executeJobfails ifcreatedJobis missing, before building the runner message.server/jobs/jobProcess.ts: a failed job keepserr.result, soresult.replicatedsurvives anERROR.components/DESIGN.md,DESIGN.md: the rule for rolling-restart outcomes and where it is enforced.result.replicatedis the per-node outcome that #3058 and #3060 (deploy record and CLI exit for rolling deploys) can consume; neither is changed here, anddeploy_component's own response is unchanged.✅ Verification
unitTests/bin/replicatedRestartService.test.jsdrivesrestartServicein a worker thread with stub peers, throughreplicatedRestartHarness.cjs. On main it completes with a failed peer, never retries back-pressure, and crashes the worker on an unhandled poll rejection.unitTests/server/jobs/executeJob.test.jsfails a real job-row encode; main reportsEmpty job passed to parseMessage.unitTests/bin/restartEachPeer.test.jscovers the retry, halt and skip rules;jobs.test.jsnow expectsaddJobto reject.test:unit:bin384 passing,test:unit:server1582 passing; integrationjob-queue,rolling-restart,canary-rollout,replication-worker-pool,redeploy-restart-flag28/28.get_jobrow onERROR.Full unit gate note
test:unit:mainhit its 50-minute timeout in the components suite (interrupted activation recovery), with failures inComponentV1.test.jsandcomponentPreparationLock.test.js. Both files pass in isolation on this branch and on main (59/59 each), and neither touches changed code.PR QA: queued qa-pr-harper-3498-b323922 @ b323922
The PR QA scenario, ready to queue
QA-PR harper#3498 — Does a real two-node rolling restart report ERROR, with per-peer outcomes, when the peer cannot restart?
Status: pending
source:pr:3498 · harper b323922 · branch fix/rolling-restart-job-outcome · base main · [two-node harper-pro cluster built against this core]
Assumption under test: A replicated
restart_service({"operation":"restart_service","service":"http","replicated":true}) endsCOMPLETEonly when every peer's own restart job completed; otherwiseget_jobshowsstatus: ERROR, a message naming the peer, andresult.replicatedwith one entry per peer ({ node, message }restarted,{ node, error }not). The origin classifies peer failures from the text and socketcodethe real replicator delivers (sendOperationToNode): a write-queue back-pressure refusal ("Outstanding write transactions have too long a queue") is retried after the other peers; "outcome there is unknown" (timeout / connection closed first) and coded socket errors other than ECONNREFUSED/ENOTFOUND/EAI_AGAIN/EHOSTUNREACH/ENETUNREACH halt the roll.Not covered by the PR's tests: the restart tests stub
server.replication.sendOperationToNodewith hand-written error strings and never runsjobProcess→updateJob→get_job. Nothing exercises the real replicator's error text/code, the persistedhdb_jobrow on ERROR, or a real peer job insert failing.Question: Build harper-pro with its
coresubmodule at the PR head (git -C core fetch origin pull/3498/head && git -C core checkout FETCH_HEAD, then build). Start two nodes A and B, cluster them (add_node), confirm replication is connected. Arms, each started from A and polled withget_jobon A until terminal:result.replicated= one{ node: B, message }entry.result.replicated(the roll does not need to halt; there is only one peer).storage.maxTransactionQueueTimeset very low on B plus a sustained write load on B, or any reproducible way to make B'shdb_jobinsert throw) → expect A's job to retry B (log line "was refused ... retrying after the other nodes") and end ERROR or COMPLETE consistent with whether B eventually accepted; B's log must not contain "Empty job passed to parseMessage".unhandledRejectionlines in A's job-thread log, job still settles.get_jobon A returnsresult.replicatedon ERROR (persisted, not only in memory).Oracle: arm 1 COMPLETE; arm 2 ERROR with B named in
messageand a{ node: "B", error: ... }entry inresult.replicated; arm 3 COMPLETE if B accepted a retry, otherwise ERROR the same way; no "Empty job passed to parseMessage" anywhere; zerounhandledRejectionlines on A in any arm. A COMPLETE in arm 2 (or in arm 3 without B's restart), a missingresult.replicatedon ERROR, or an unhandled rejection is a finding.Regression A/B: on failure, repeat with
coreat the PR's merge base on main (d4f9fb9); expected there: arm 2 reports COMPLETE with B's failure buried inresult.replicated(the reported bug), arm 3 shows "Empty job passed to parseMessage".Axes: replication (operation forwarding), jobs lifecycle (hdb_job persistence), restart lifecycle, mixed failure classes (refused / unreachable / dropped mid-poll); storage RocksDB.
Docs: queued docs-harper-3498.
Written by Claude Opus 5.5 (dev-agent) for @kriszyp.
Release target: v5.3 — reported on 5.3.3 in production and the same code is on v5.3; milestone left for Kris to set
Related PRs: 2 independent
Complexity: medium
🤖 Generated with Claude Code
Deferred findings
0e1ac605d39a8fd2minor concurrency —bin/restart.ts— activateEachPeer continues to next peer after outcome-unknown / connection-closed dispatch failure, allowing overlapping peer restarts — NOT FILED4deaca79938c53f6nit reliability —bin/restart.ts— back-pressure retry keyed on message text — filed Classify a replicated operation's peer failure by status, not message text #3496f59c6bbabccf3902nit test-coverage —unitTests/server/jobs/executeJob.test.js— no regression check that a statusCode-bearing addJob error passes through executeJob unwrapped — NOT FILEDOrigin — the dispatch brief this PR was written from
Fix harper#3468 on main: a replicated restart_service job (the job behind deploy_component with restart: "rolling") reports status COMPLETE even when a node was never restarted, and a failed job insert on a peer is reported as the misleading "Empty job passed to parseMessage". Address all four defects listed in the issue: (1) bin/restart.ts pushes { node, message } and continues when sendOperationToNode throws, then returns { replicated }, so the job completes normally; make the job end in ERROR naming every node that failed or was skipped, keeping the per-node result.replicated entries. (2) server/jobs/jobs.ts addJob catches the insert error and returns { success: false } with no createdJob; executeJob (server/serverHelpers/serverUtilities.ts) only checks
if (result)and builds a RunnerMessage with job undefined, so parseMessage throws. Make executeJob check success/createdJob and surface the original error (e.g. the write-queue back-pressure error). (3) Retry a node whose failure is a transient back-pressure error (the 'Outstanding write transactions have too long of queue, please try again later' class) a bounded number of times, for example re-queueing it after the remaining nodes, before marking it failed. (4) The get_job poll in bin/restart.ts runs in setInterval(async () => ...) with no try/catch, producing unhandledRejection (Connection closed 1006 / socket disconnected before TLS established) while a peer restarts; handle those and keep polling.Acceptance
Unit tests: a restart job with one peer whose operation throws ends ERROR and names that peer; a peer whose job insert fails surfaces the original insert error from executeJob, not 'Empty job passed to parseMessage'; a transient back-pressure failure that succeeds on retry leaves the job COMPLETE; a peer connection drop during the get_job poll produces no unhandled rejection. Each new test fails on unmodified main. Existing restart/deploy unit and integration suites pass. Do not change deploy_component's own response or the deploy record (harper#3058/#3060 scope). Draft PR only.
Dispatch: task
harper-3468-rolling-restart-outcome· queued by kris-session · ran by claude/opus/high · worker kzyp-xps-1Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok,muse; rounds=7; full=3 @ b323922
Review-Attention: read ~5m (decisions: halt-on-unclassified-socket-error, halt-on-peer-job-failure, addjob-throws, do-less-alternative, test-only-export) @ b323922